/*
 * Unidata Platform Community Edition
 * Copyright (c) 2013-2020, UNIDATA LLC, All rights reserved.
 * This file is part of the Unidata Platform Community Edition software.
 *
 * Unidata Platform Community Edition is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Unidata Platform Community Edition is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 */

package org.unidata.mdm.search.service.impl;

import static org.unidata.mdm.search.type.form.FormField.FilteringType.NEGATIVE;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.lucene.search.join.ScoreMode;
import org.elasticsearch.action.ActionRequestBuilder;
import org.elasticsearch.action.ActionResponse;
import org.elasticsearch.common.unit.Fuzziness;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.InnerHitBuilder;
import org.elasticsearch.index.query.MatchQueryBuilder;
import org.elasticsearch.index.query.MultiMatchQueryBuilder;
import org.elasticsearch.index.query.NestedQueryBuilder;
import org.elasticsearch.index.query.Operator;
import org.elasticsearch.index.query.PrefixQueryBuilder;
import org.elasticsearch.index.query.QueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.index.query.QueryStringQueryBuilder;
import org.elasticsearch.index.query.RangeQueryBuilder;
import org.elasticsearch.index.query.TermQueryBuilder;
import org.elasticsearch.index.search.MatchQuery;
import org.elasticsearch.join.query.HasChildQueryBuilder;
import org.elasticsearch.join.query.HasParentQueryBuilder;
import org.elasticsearch.join.query.JoinQueryBuilders;
import org.elasticsearch.search.aggregations.AbstractAggregationBuilder;
import org.elasticsearch.search.aggregations.AggregationBuilders;
import org.elasticsearch.search.aggregations.bucket.filter.FilterAggregationBuilder;
import org.elasticsearch.search.aggregations.bucket.nested.NestedAggregationBuilder;
import org.elasticsearch.search.aggregations.bucket.nested.ReverseNestedAggregationBuilder;
import org.elasticsearch.search.aggregations.bucket.terms.IncludeExclude;
import org.elasticsearch.search.aggregations.bucket.terms.TermsAggregationBuilder;
import org.elasticsearch.search.fetch.subphase.FetchSourceContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.unidata.mdm.search.configuration.SearchConfigurationConstants;
import org.unidata.mdm.search.context.AggregationSearchContext;
import org.unidata.mdm.search.context.CardinalityAggregationRequestContext;
import org.unidata.mdm.search.context.ComplexSearchRequestContext;
import org.unidata.mdm.search.context.FilterAggregationRequestContext;
import org.unidata.mdm.search.context.NestedAggregationRequestContext;
import org.unidata.mdm.search.context.NestedSearchRequestContext;
import org.unidata.mdm.search.context.NestedSearchRequestContext.NestedSearchType;
import org.unidata.mdm.search.context.ReverseNestedAggregationRequestContext;
import org.unidata.mdm.search.context.SearchRequestContext;
import org.unidata.mdm.search.context.TermsAggregationRequestContext;
import org.unidata.mdm.search.context.TypedSearchContext;
import org.unidata.mdm.search.context.ValueCountAggregationRequestContext;
import org.unidata.mdm.search.exception.SearchApplicationException;
import org.unidata.mdm.search.exception.SearchExceptionIds;
import org.unidata.mdm.search.type.FieldType;
import org.unidata.mdm.search.type.HierarchicalIndexType;
import org.unidata.mdm.search.type.IndexField;
import org.unidata.mdm.search.type.IndexType;
import org.unidata.mdm.search.type.form.FieldsGroup;
import org.unidata.mdm.search.type.form.FormField;
import org.unidata.mdm.search.type.query.FormSearchQuery;
import org.unidata.mdm.search.type.query.MatchSearchQuery;
import org.unidata.mdm.search.type.query.StringSearchQuery;
import org.unidata.mdm.search.util.SearchUtils;
import org.unidata.mdm.system.type.annotation.ConfigurationRef;
import org.unidata.mdm.system.type.configuration.ConfigurationValue;
import org.unidata.mdm.system.type.runtime.MeasurementPoint;
import org.unidata.mdm.system.util.ConvertUtils;

/**
 * @author Mikhail Mikhailov
 *         Common methods for search components.
 */
public class BaseComponentImpl {
    /**
     * The logger.
     */
    private static final Logger LOGGER = LoggerFactory.getLogger(BaseComponentImpl.class);
    /**
     * default min score for search
     */
    @ConfigurationRef(SearchConfigurationConstants.PROPERTY_FUZZINESS)
    private ConfigurationValue<String> defaultFuzziness;
    /**
     * default min score for search
     */
    @ConfigurationRef(SearchConfigurationConstants.PROPERTY_FUZZINESS_PREFIX_LENGTH)
    private ConfigurationValue<Long> fuzzinessPrefixLength;
    /**
     * Search nodes.
     */
    @ConfigurationRef(SearchConfigurationConstants.PROPERTY_INDEX_PREFIX)
    private ConfigurationValue<String> indexPrefix;
    /**
     * Maximum search window size, used in paginated requests.
     */
    @ConfigurationRef(SearchConfigurationConstants.PROPERTY_HITS_LIMIT)
    protected ConfigurationValue<Long> maxWindowSize;
    /**
     * Use fuzziness also with wildcards.
     */
    @ConfigurationRef(SearchConfigurationConstants.PROPERTY_FUZZINESS_WITH_WILDCARD)
    private ConfigurationValue<Boolean> wildcardSearch;
    /**
     * @param formFields
     * @return bool filter
     */
    protected QueryBuilder createFormFilter(Collection<FieldsGroup> formFields) {

        BoolQueryBuilder topFilter = QueryBuilders.boolQuery();
        for (FieldsGroup groupFormFields : formFields) {

            if (groupFormFields.isEmpty()) {
                continue;
            }

            BoolQueryBuilder groupBoolFilter = QueryBuilders.boolQuery();
            if (groupFormFields.getType() == FieldsGroup.GroupType.OR) {
                groupFormFields.getFields().stream()
                        .map(this::getFilter)
                        .filter(Objects::nonNull)
                        .forEach(groupBoolFilter::should);
            } else {
                groupFormFields.getFields().stream()
                        .map(this::getFilter)
                        .filter(Objects::nonNull)
                        .forEach(groupBoolFilter::must);
            }

            if (groupBoolFilter.hasClauses()) {
                topFilter.must(groupBoolFilter);
            }

            if (CollectionUtils.isNotEmpty(groupFormFields.getGroups())) {

                if (groupFormFields.getType() == FieldsGroup.GroupType.OR) {
                    groupFormFields.getGroups().stream()
                            .filter(Objects::nonNull)
                            .forEach(childGroup ->
                            topFilter.should(createInnerFormFilter(childGroup)));
                } else {
                    groupFormFields.getGroups().stream()
                            .filter(Objects::nonNull)
                            .forEach(childGroup ->
                            topFilter.must(createInnerFormFilter(childGroup)));
                }
            }
        }

        if (topFilter.hasClauses()) {
            return topFilter;
        } else {
            return QueryBuilders.matchAllQuery();
        }
    }

    private QueryBuilder createInnerFormFilter(FieldsGroup groupFilter) {

        BoolQueryBuilder topFilter = QueryBuilders.boolQuery();
        if (groupFilter.getType() == FieldsGroup.GroupType.OR) {
            groupFilter.getFields().stream()
                    .map(this::getFilter)
                    .filter(Objects::nonNull)
                    .forEach(topFilter::should);
        } else {
            groupFilter.getFields().stream()
                    .map(this::getFilter)
                    .filter(Objects::nonNull)
                    .forEach(topFilter::must);
        }

        if (CollectionUtils.isNotEmpty(groupFilter.getGroups())) {
            if (groupFilter.getType() == FieldsGroup.GroupType.OR) {
                groupFilter.getGroups().forEach(
                        childGroup -> topFilter.should(createInnerFormFilter(childGroup)));
            } else {
                groupFilter.getGroups().forEach(
                        childGroup -> topFilter.must(createInnerFormFilter(childGroup)));
            }
        }

        if (topFilter.hasClauses()) {
            return topFilter;
        } else {
            return QueryBuilders.matchAllQuery();
        }
    }

    /**
     * @param formField
     * @return specified filter
     */
    @Nullable
    private QueryBuilder getFilter(@Nonnull FormField formField) {

        String path = formField.getPath();
        QueryBuilder filterBuilder = null;

        switch (formField.getSearchType()) {
            case EXIST:
                filterBuilder = QueryBuilders.existsQuery(path);
                break;
            case START_WITH:
                filterBuilder = createStartWithQuery(formField, false);
                break;
            case LIKE:
                filterBuilder = createWildcardQuery(path, formField.getSingleValue().toString());
                break;
            case RANGE:
                filterBuilder = createRangeFilter(formField);
                break;
            case FUZZY:
                filterBuilder = createFuzzyQueryFromFormField(formField);
                break;
            case LEVENSHTEIN:
                filterBuilder = createLevenshteinQueryFromFormField(formField);
                break;
            case EXACT:
                filterBuilder = createTermQuery(formField);
                break;
            case MORPHOLOGICAL:
                filterBuilder = createMorphologicallyQueryFromFormField(formField);
                break;
            case NONE_MATCH:
                filterBuilder = QueryBuilders.boolQuery().mustNot(QueryBuilders.matchAllQuery());
                break;
            case DEFAULT:
                filterBuilder = createTermQuery(formField);
                break;
        }

        //change with UN-5115 (remove exist restriction)
        if (filterBuilder != null && formField.getFormType() == NEGATIVE) {
            filterBuilder = QueryBuilders.boolQuery().mustNot(filterBuilder);
        }

        //we support nested object just for $dq_errors.
        return filterBuilder;
    }

    /**
     * @param formField field
     * @return term filter by field
     */
    private QueryBuilder createTermQuery(FormField formField) {

        String fieldName = formField.isAnalyzed()
                ? stringNonAnalyzedField(formField.getPath())
                : formField.getPath();

        if (formField.isNull()) {
            return QueryBuilders.boolQuery().mustNot(QueryBuilders.existsQuery(fieldName));
        } else if (formField.isMultiValue()) {
            return QueryBuilders.termsQuery(fieldName, formField.getValues());
        } else {
            return QueryBuilders.termQuery(fieldName, formField.getSingleValue());
        }

    }

    /**
     * @param initialFieldName
     * @return correct field name
     */
    protected String stringNonAnalyzedField(String initialFieldName) {
        return initialFieldName.contains(SearchUtils.DOLLAR) || initialFieldName.equals(SearchUtils.ID_FIELD)
                ? initialFieldName
                : initialFieldName + SearchUtils.DOT + SearchUtils.NAN_FIELD;
    }


    /**
     * @param initialFieldName
     * @return correct field name
     */
    protected String stringSearchAnalyzedField(String initialFieldName) {
        return initialFieldName.contains(SearchUtils.DOLLAR) || initialFieldName.equals(SearchUtils.ID_FIELD)
                ? initialFieldName
                : initialFieldName + SearchUtils.DOT + SearchUtils.DEFAULT_SEARCH_ANALIZED_FIELD;
    }

    /**
     * @param initialFieldName
     * @return correct field name
     */
    private String stringMorphologicallyAnalyzedField(String initialFieldName) {
        return initialFieldName.contains(SearchUtils.DOLLAR) ?
                initialFieldName :
                initialFieldName + SearchUtils.DOT + SearchUtils.MORPH_FIELD;
    }

    /**
     * @param formField
     * @return range query
     */
    private QueryBuilder createRangeFilter(FormField formField) {

        Object left = formField.getRange().getLeftBoundary();
        Object right = formField.getRange().getRightBoundary();

        if (left == null && right == null) {
            return null;
        }

        RangeQueryBuilder rangeFilter = QueryBuilders.rangeQuery(formField.getPath());
        if (left != null) {
            Long timeMillis = toTimeMillis(left);
            rangeFilter.gte(timeMillis != null ? timeMillis : left);
        }

        if (right != null) {
            Long timeMillis = toTimeMillis(right);
            rangeFilter.lte(timeMillis != null ? timeMillis : right);
        }
        return rangeFilter;
    }

    private QueryBuilder createMorphologicallyQueryFromFormField(FormField formField) {
        return createFuzzyQuery(formField.getSingleValue() == null ? null : formField.getSingleValue().toString(),
                stringMorphologicallyAnalyzedField(formField.getPath()), true);
    }

    private QueryBuilder createFuzzyQueryFromFormField(FormField formField) {
        return createFuzzyQuery(formField.getSingleValue() == null ? null : formField.getSingleValue().toString(),
                formField.getPath(), false);
    }

    private QueryBuilder createLevenshteinQueryFromFormField(FormField formField) {

        String path = formField.isAnalyzed()
                ? stringNonAnalyzedField(formField.getPath())
                : formField.getPath();

        return createLevenshteinQuery(formField.getSingleValue() == null ? null : formField.getSingleValue().toString(),
                path);
    }

    /**
     * Split fields list to sting and not string fields
     * @param ctx  search context
     *
     * @return pair first list is string fields, second not string fields
     */
    protected Pair<Map<String, Float>, Map<String, Float>> splitStringAttributes(SearchRequestContext ctx) {

        MatchSearchQuery msq = ctx.getMatchQuery();
        Map<String, Float> scoreFields = ctx.getScoreFields();
        Map<String, Float> stringAttrs = new HashMap<>();
        Map<String, Float> notStringAttrs = new HashMap<>();
        for (IndexField field : msq.getFields()) {

            if (field.getFieldType() == FieldType.STRING) {
                stringAttrs.put(field.getName(), scoreFields.getOrDefault(field.getName(), 1f));
            } else {
                notStringAttrs.put(field.getName(), scoreFields.getOrDefault(field.getName(), 1f));
            }
        }

        return Pair.of(stringAttrs, notStringAttrs);
    }

    /**
     * Creates query builder from context.
     *
     * @param ctx the context
     * @param nested nested contexts collection
     * @return query builder
     */
    @Nonnull
    protected QueryBuilder createGeneralQueryFromContext(final SearchRequestContext ctx, Collection<NestedSearchRequestContext> nested) {

        if (ctx.isFetchAll()) {
            return QueryBuilders.matchAllQuery();
        }

        if (ctx.isSayt()) {
            return createSaytQueryFromContext(ctx);
        }

        boolean isEmpty = ctx.isEmpty();
        if (isEmpty) {
            return QueryBuilders.boolQuery().mustNot(QueryBuilders.matchAllQuery());
        }

        boolean hasString = ctx.hasStringQuery();
        boolean hasMatch = ctx.hasMatchQuery();
        boolean hasForm = ctx.hasFormQuery();

        QueryBuilder formFilter = null;
        QueryBuilder simpleResult = null;

        if (hasForm) {
            formFilter = createFormFilter(ctx.getFormQuery().getGroups());
        }

        if (hasString || hasMatch) {
            simpleResult = createSimpleQuery(ctx);
        }

        if (CollectionUtils.isNotEmpty(nested)) {

            BoolQueryBuilder joinChildrenFilter = QueryBuilders.boolQuery();
            for (NestedSearchRequestContext entry : nested) {
                processNestedContext(entry, joinChildrenFilter);
            }

            if (formFilter != null) {
                joinChildrenFilter.must(formFilter);
            }

            formFilter = joinChildrenFilter;
        }

        BoolQueryBuilder queryResult;
        if (simpleResult != null) {
            queryResult = QueryBuilders
                    .boolQuery()
                    .must(simpleResult);
        } else {
            queryResult = QueryBuilders
                    .boolQuery()
                    .must(QueryBuilders.matchAllQuery());
        }

        if (formFilter != null) {
            if (ctx.isScoreEnabled()) {
                queryResult.must(formFilter);
            } else {
                queryResult.filter(formFilter);
            }
        }

        return queryResult;
    }

    private void processNestedContext(NestedSearchRequestContext nsCtx, BoolQueryBuilder joiner) {

        SearchRequestContext nestedCtx = nsCtx.getNestedSearch();
        // For nested object query
        // nested -> must not ->  term
        // not work.
        // Instead we need use another order
        // must not -> nested - term
        // for all negative fields and group fields

        List<FieldsGroup> specialGroupFields = null;
        if (nsCtx.getNestedSearchType() == NestedSearchType.NESTED_OBJECTS
                && nestedCtx.hasFormQuery()
                && nestedCtx.getFormQuery().getGroups().size() == 1) {

            FieldsGroup forCopy = nestedCtx.getFormQuery().getGroups().get(0);
            final FieldsGroup specialGroup = forCopy.getType() == FieldsGroup.GroupType.AND
                    ? FieldsGroup.and()
                    : FieldsGroup.or();

            if (forCopy.getFields() != null && forCopy.getFields().stream()
                    .anyMatch(formField -> formField.getFormType() == FormField.FilteringType.NEGATIVE)) {
                forCopy.getFields().stream()
                        .filter(formField -> formField.getFormType() == FormField.FilteringType.NEGATIVE)
                        .forEach(formField -> specialGroup.add(FormField.invert(formField)));
                if (specialGroupFields == null) {
                    specialGroupFields = new ArrayList<>();
                }
                specialGroupFields.add(specialGroup);
                forCopy.getFields().removeIf(formField -> formField.getFormType() == FormField.FilteringType.NEGATIVE);
            }

            if (forCopy.getGroups() != null) {
                for (FieldsGroup childGroup : forCopy.getGroups()) {
                    if (childGroup.getFields() != null && childGroup.getFields().stream()
                            .anyMatch(formField -> formField.getFormType() == FormField.FilteringType.NEGATIVE)) {
                        if (specialGroupFields == null) {
                              specialGroupFields = new ArrayList<>();
                        }
                        FieldsGroup copyGroup = childGroup.getType() == FieldsGroup.GroupType.AND
                                ? FieldsGroup.or()
                                : FieldsGroup.and();
                        for (FormField field : childGroup.getFields()) {
                            copyGroup.add(FormField.invert(field));
                        }
                        specialGroupFields.add(copyGroup);
                    }
                }
                forCopy.getGroups().removeIf(childGroup -> childGroup.getFields() != null && childGroup.getFields().stream()
                        .anyMatch(formField -> formField.getFormType() == FormField.FilteringType.NEGATIVE));
            }


            if (CollectionUtils.isEmpty(forCopy.getFields()) && CollectionUtils.isEmpty(forCopy.getGroups())) {
                nestedCtx.getFormQuery().getGroups().remove(forCopy);
            }
        }

        QueryBuilder innerQuery = createGeneralQueryFromContext(nestedCtx, nestedCtx.getNestedSearch());

        if (nsCtx.getNestedSearchType() == NestedSearchType.HAS_CHILD) {

            HasChildQueryBuilder hasChild
                = JoinQueryBuilders.hasChildQuery(
                        nestedCtx.getType().getName(), innerQuery, ScoreMode.None);

            if (nsCtx.getMinDocCount() != null) {
                hasChild.minMaxChildren(nsCtx.getMinDocCount(), Integer.MAX_VALUE);
            }

            if (CollectionUtils.isNotEmpty(nestedCtx.getReturnFields())) {
                hasChild.innerHit(createInnerHitBuilder(nsCtx));
            }
            if (nsCtx.isPositive()) {
                joiner.must(hasChild);
            } else {
                joiner.mustNot(hasChild);
            }
        }

        if (nsCtx.getNestedSearchType() == NestedSearchType.HAS_PARENT) {

            HasParentQueryBuilder hasParent
                    = JoinQueryBuilders.hasParentQuery(
                    nestedCtx.getType().getName(), innerQuery, false);

            if (CollectionUtils.isNotEmpty(nestedCtx.getReturnFields())) {
                hasParent.innerHit(createInnerHitBuilder(nsCtx));
            }
            if (nsCtx.isPositive()) {
                joiner.must(hasParent);
            } else {
                joiner.mustNot(hasParent);
            }
        } else if (nsCtx.getNestedSearchType() == NestedSearchType.NESTED_OBJECTS) {

            NestedQueryBuilder nqb = QueryBuilders.nestedQuery(nestedCtx.getNestedPath(), innerQuery, ScoreMode.None);
            if (CollectionUtils.isNotEmpty(nestedCtx.getReturnFields())) {
                nqb.innerHit(createInnerHitBuilder(nsCtx));
            }

            if (specialGroupFields != null) {
                QueryBuilder specialBuilder = createFormFilter(specialGroupFields);

                joiner.must(QueryBuilders.boolQuery()
                        .should(nqb)
                        .should(QueryBuilders.boolQuery().mustNot(
                                QueryBuilders.nestedQuery(nestedCtx.getNestedPath(), specialBuilder, ScoreMode.None))));

            } else {
                if (nsCtx.isPositive()) {
                    joiner.must(nqb);
                } else {
                    joiner.mustNot(nqb);
                }
            }
        }
    }

    private InnerHitBuilder createInnerHitBuilder(NestedSearchRequestContext nsCtx) {

        SearchRequestContext nestedCtx = nsCtx.getNestedSearch();
        FetchSourceContext fetchSourceContext
            = new FetchSourceContext(
                true,
                nestedCtx.getReturnFields() == null
                        ? null
                        : nestedCtx.getReturnFields().toArray(new String[nestedCtx.getReturnFields().size()]),
                null);

        InnerHitBuilder innerHitBuilder = new InnerHitBuilder();
        innerHitBuilder.setName(nsCtx.getNestedQueryName());
        innerHitBuilder.setFetchSourceContext(fetchSourceContext);
        innerHitBuilder.setSize(nestedCtx.getCount());

        return innerHitBuilder;
    }

    /**
     * Creates a simple query.
     *
     * @param ctx the context
     * @return {@link QueryBuilder}
     */
    protected QueryBuilder createSimpleQuery(SearchRequestContext ctx) {

        QueryBuilder queryBuilder = null;
        if (ctx.hasMatchQuery()) {
            queryBuilder = createFuzzyQuery(ctx);
        } else if (ctx.hasStringQuery()) {
            queryBuilder = createQStringQuery(ctx);
        }

        return queryBuilder;
    }


    /**
     * Creates a wildcard query.
     *
     * @param field search field
     * @param text  text
     * @return query builder
     */
    private QueryBuilder createWildcardQuery(String field, String text) {
        text = text.replace("*", "\\*").replace("?", "\\?");
        if (text == null) {
            return QueryBuilders.boolQuery().mustNot(QueryBuilders.existsQuery(field));
        }

        boolean hasMoreThenOneTerm = text.contains(StringUtils.SPACE);
        if (hasMoreThenOneTerm) {
            List<String> terms = Arrays.asList(StringUtils.split(text, StringUtils.SPACE));
            BoolQueryBuilder q = QueryBuilders.boolQuery();
            terms.forEach(s -> q.must(QueryBuilders.wildcardQuery(field, "*" + s.toLowerCase() + "*")));
            return q;
        } else {
            // UN-5293
            return QueryBuilders.wildcardQuery(field, "*" + text.toLowerCase() + "*");
        }
    }

    /**
     * Creates a query string query.
     *
     * @param ctx the context
     * @return query builder
     */
    protected QueryBuilder createQStringQuery(final SearchRequestContext ctx) {

        StringSearchQuery ssq = ctx.getStringQuery();
        QueryStringQueryBuilder builder = QueryBuilders
                .queryStringQuery(ssq.getQuery())
                .lenient(true);

        builder.phraseSlop(SearchUtils.DEFAULT_SLOP_VALUE);
        return builder;
    }

    protected QueryBuilder createSaytQueryFromContext(final SearchRequestContext ctx) {
        MatchSearchQuery msq = ctx.getMatchQuery();
        if (msq.getFields().size() == 1) {
            return QueryBuilders
                    .matchQuery(msq.getFields().get(0).getName(), msq.getText())
                    .maxExpansions(SearchUtils.DEFAULT_MAX_EXPANSIONS_VALUE)
                    .operator(Operator.AND)
                    .analyzer(SearchUtils.STANDARD_STRING_ANALYZER_NAME)
                    .zeroTermsQuery(MatchQuery.ZeroTermsQuery.ALL)
                    .lenient(true);
        } else {
            return QueryBuilders
                    .multiMatchQuery(msq.getText(), msq.getFields().stream().map(IndexField::getName).toArray(String[]::new))
                    .maxExpansions(SearchUtils.DEFAULT_MAX_EXPANSIONS_VALUE)
                    .slop(SearchUtils.DEFAULT_SLOP_VALUE)
                    .operator(Operator.AND)
                    .analyzer(SearchUtils.STANDARD_STRING_ANALYZER_NAME)
                    .zeroTermsQuery(MatchQuery.ZeroTermsQuery.ALL)
                    .lenient(true);
        }
    }

    /**
     * Creates a match query.
     *
     * @param text                  - text
     * @param fields                - fields
     * @param searchMorphologically search using morphological analyzer
     * @return query builder
     */
    private QueryBuilder createMatchQuery(String text, Collection<String> fields, Map<String, Float> scoreFields, boolean searchMorphologically) {

        Operator operator = Operator.AND;
        if (fields.size() == 1) {

            MatchQueryBuilder matchQueryBuilder = QueryBuilders.matchQuery(fields.iterator().next(), text)
                    .maxExpansions(SearchUtils.DEFAULT_MAX_EXPANSIONS_VALUE)
                    .operator(operator)
                    .analyzer(searchMorphologically
                            ? SearchUtils.MORPH_STRING_ANALYZER_NAME
                            : SearchUtils.STANDARD_STRING_ANALYZER_NAME)
                    .lenient(true);

            if (MapUtils.isNotEmpty(scoreFields)) {
                matchQueryBuilder.boost(scoreFields.get(fields.iterator().next()));
            }

            return matchQueryBuilder;
        } else {

            boolean hasMoreThenOneTerm = text.contains(StringUtils.SPACE);
            MultiMatchQueryBuilder.Type type = hasMoreThenOneTerm ?
                    MultiMatchQueryBuilder.Type.CROSS_FIELDS :
                    MultiMatchQueryBuilder.Type.MOST_FIELDS;
            MultiMatchQueryBuilder multiMatchQueryBuilder = QueryBuilders.multiMatchQuery(text, fields.toArray(new String[fields.size()]))
                    .maxExpansions(SearchUtils.DEFAULT_MAX_EXPANSIONS_VALUE)
                    .slop(SearchUtils.DEFAULT_SLOP_VALUE)
                    .operator(operator)
                    .analyzer(searchMorphologically
                            ? SearchUtils.MORPH_STRING_ANALYZER_NAME
                            : SearchUtils.STANDARD_STRING_ANALYZER_NAME)
                    .type(type)
                    .lenient(true);

            if (MapUtils.isNotEmpty(scoreFields)) {
                multiMatchQueryBuilder.fields(scoreFields);
            }

            return multiMatchQueryBuilder;
        }
    }

    /**
     * Creates a match query.
     *
     * @param searchMorphologically search using morphological analyzer
     * @return query builder
     */
    private QueryBuilder createStartWithQuery(FormField formField, boolean searchMorphologically) {

        String text = formField.getSingleValue() == null ? null : formField.getSingleValue().toString();
        String field = formField.getPath();
        Operator operator = Operator.AND;
        MatchQueryBuilder matchQueryBuilder = QueryBuilders.matchQuery(field, text)
                .maxExpansions(SearchUtils.DEFAULT_MAX_EXPANSIONS_VALUE)
                .operator(operator)
                .analyzer(searchMorphologically
                        ? SearchUtils.MORPH_STRING_ANALYZER_NAME
                        : SearchUtils.STANDARD_STRING_ANALYZER_NAME)
                .lenient(true);

        // Do not amend the path for not analyzed fields
        PrefixQueryBuilder prefixQueryBuilder = QueryBuilders.prefixQuery(formField.isAnalyzed() ? stringNonAnalyzedField(field) : field, text);
        TermQueryBuilder termQueryBuilder = QueryBuilders.termQuery(formField.isAnalyzed() ? stringNonAnalyzedField(field) : field, text);

        return QueryBuilders.boolQuery()
                .should(matchQueryBuilder)
                .should(prefixQueryBuilder)
                .should(termQueryBuilder)
                .minimumShouldMatch(1);
    }

    /**
     * Creates a fuzzy search query.
     *
     * @param ctx the context
     * @return query builder
     */
    protected QueryBuilder createFuzzyQuery(final SearchRequestContext ctx) {

        MatchSearchQuery msq = ctx.getMatchQuery();
        Map<String, Float> scoreFields = ctx.getScoreFields();
        Pair<Map<String, Float>, Map<String, Float>> parsedFields = splitStringAttributes(ctx);
        QueryBuilder result = null;
        if (parsedFields != null && (MapUtils.isNotEmpty(parsedFields.getLeft()) || MapUtils.isNotEmpty(parsedFields.getRight()))) {
            BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
            result = boolQuery;
            if (MapUtils.isNotEmpty(parsedFields.getLeft())) {
                boolQuery.should(createFuzzyQuery(msq.getText(), parsedFields.getLeft().keySet(), parsedFields.getLeft()));
                QueryBuilder textNonAnalizedMatchQuery = createMatchQuery(msq.getText(), parsedFields.getLeft().keySet().stream()
                        .map(this::stringSearchAnalyzedField).collect(Collectors.toList()),
                    Collections.emptyMap(), false);
                textNonAnalizedMatchQuery.boost(2f);
                // Add optional wildcard query
                if (wildcardSearch.getValue().booleanValue()) {
                    BoolQueryBuilder wildcardQuery = QueryBuilders.boolQuery();
                    parsedFields.getLeft().forEach((field, boost) ->
                        wildcardQuery.should(createWildcardQuery(field, msq.getText()).boost(boost)));
                    boolQuery.should(wildcardQuery);
                }
            }

            if (MapUtils.isNotEmpty(parsedFields.getRight())) {
                boolQuery.should(createMatchQuery(msq.getText(), parsedFields.getRight().keySet(), parsedFields.getRight(), false));
            }
        } else {
            result = QueryBuilders.matchAllQuery();
        }

        return (MapUtils.isEmpty(scoreFields)  && !ctx.isScoreEnabled())
            ? QueryBuilders.constantScoreQuery(result)
            : result;
    }

    private QueryBuilder createFuzzyQuery(String text, String field, boolean searchMorphologically) {
        if (text == null) {
            return QueryBuilders.boolQuery().mustNot(QueryBuilders.existsQuery(field));
        }
        String fieldWithoutNGramm = stringSearchAnalyzedField(field);
        BoolQueryBuilder result = QueryBuilders.boolQuery();
        // 1. Exact query with boost
        QueryBuilder exactQuery = QueryBuilders.termQuery(fieldWithoutNGramm, text);
        exactQuery.boost(2f);
        result.should(exactQuery);
        // 2. Fuzzy search by nGramm
        QueryBuilder fuzzyQuery = QueryBuilders.matchQuery(field, text)
            .maxExpansions(SearchUtils.DEFAULT_MAX_EXPANSIONS_VALUE)
            .operator(Operator.AND)
            .analyzer(searchMorphologically
                ? SearchUtils.MORPH_STRING_ANALYZER_NAME
                : SearchUtils.SEARCH_STRING_ANALYZER_NAME)
            .prefixLength(fuzzinessPrefixLength.getValue().intValue())
            .fuzziness(Fuzziness.build(defaultFuzziness.getValue()))
            .lenient(true);
        result.should(fuzzyQuery);
        // 3. Optional wildcard query
        if (wildcardSearch.getValue()) {
            String wildcardText = text.replace("*", "\\*").replace("?", "\\?");
            QueryBuilder wildcardQuery;
            boolean hasMoreThenOneTerm = wildcardText.contains(StringUtils.SPACE);
            if (hasMoreThenOneTerm) {
                List<String> terms = Arrays.asList(StringUtils.split(wildcardText, StringUtils.SPACE));
                BoolQueryBuilder q = QueryBuilders.boolQuery();
                terms.forEach(s -> q.must(QueryBuilders.wildcardQuery(fieldWithoutNGramm, "*" + s.toLowerCase() + "*")));
                wildcardQuery = q;
            } else {
                wildcardQuery = QueryBuilders.wildcardQuery(fieldWithoutNGramm, "*" + wildcardText.toLowerCase() + "*");
            }
            result.should(wildcardQuery);
        }

        return result;
    }

    private QueryBuilder createLevenshteinQuery(String text, String field) {
        if (text == null) {
            return QueryBuilders.boolQuery().mustNot(QueryBuilders.existsQuery(field));
        }
        return QueryBuilders.matchQuery(field, text)
                .maxExpansions(SearchUtils.DEFAULT_MAX_EXPANSIONS_VALUE)
                .operator(Operator.AND)
                .fuzziness(Fuzziness.build(defaultFuzziness.getValue()))
                .lenient(true);
    }

    /**
     * Creates a fuzzy search query.
     *
     * @param text   - text
     * @param fields - fields
     * @return query builder
     */
    private QueryBuilder createFuzzyQuery(String text, Collection<String> fields, Map<String, Float> scoreFields) {

        if (fields.size() == 1) {

            boolean hasMoreThenOneTerm = text.contains(StringUtils.SPACE);
            Operator operator = hasMoreThenOneTerm ?
                    Operator.OR :
                    Operator.AND;
            MatchQueryBuilder matchQueryBuilder = QueryBuilders.matchQuery(fields.iterator().next(), text)
                    .maxExpansions(SearchUtils.DEFAULT_MAX_EXPANSIONS_VALUE)
                    .operator(operator)
                    .analyzer(SearchUtils.STANDARD_STRING_ANALYZER_NAME)
                    .prefixLength(fuzzinessPrefixLength.getValue().intValue())
                    .fuzziness(Fuzziness.build(defaultFuzziness.getValue()))
                    .lenient(true);

            if (MapUtils.isNotEmpty(scoreFields)) {
                matchQueryBuilder.boost(scoreFields.get(fields.iterator().next()));
            }

            return matchQueryBuilder;
        } else {

            boolean hasMoreThenOneTerm = text.contains(StringUtils.SPACE);
            Operator operator = hasMoreThenOneTerm ?
                    Operator.OR :
                    Operator.AND;
            MultiMatchQueryBuilder multiMatchQueryBuilder = QueryBuilders.multiMatchQuery(text, fields.toArray(new String[fields.size()]))
                    .maxExpansions(SearchUtils.DEFAULT_MAX_EXPANSIONS_VALUE)
                    .slop(SearchUtils.DEFAULT_SLOP_VALUE)
                    .operator(operator)
                    .analyzer(SearchUtils.STANDARD_STRING_ANALYZER_NAME)
                    .prefixLength(fuzzinessPrefixLength.getValue().intValue())
                    .type(MultiMatchQueryBuilder.Type.BEST_FIELDS)
                    .fuzziness(Fuzziness.build(defaultFuzziness))
                    .lenient(true);

            if (MapUtils.isNotEmpty(scoreFields)) {
                multiMatchQueryBuilder.fields(scoreFields);
            }

            return multiMatchQueryBuilder;
        }
    }

    /**
     * @param complex - complex search request
     * @return map, where key it is a simple search request and value it is a query
     */
    @Nonnull
    protected Map<SearchRequestContext, QueryBuilder> createQueryForComplex(final ComplexSearchRequestContext complex) {

        if (complex.isEmpty()) {
            return Collections.emptyMap();
        }

        Map<SearchRequestContext, QueryBuilder> result = new IdentityHashMap<>(complex.getSupplementary().size() + 1);
        if (complex.getType() == ComplexSearchRequestContext.ComplexSearchRequestType.HIERARCHICAL) {

            SearchRequestContext main = complex.getMain();
            QueryBuilder baseQuery = createGeneralQueryFromContext(main, complex.getNested(main));
            HierarchicalIndexType fromType = (HierarchicalIndexType) main.getType();

            if (!complex.getSupplementary().isEmpty()) {

                BoolQueryBuilder joinChildrenFilter = QueryBuilders.boolQuery();
                for (SearchRequestContext related : complex.getSupplementary()) {

                    HierarchicalIndexType toType = (HierarchicalIndexType) related.getType();
                    QueryBuilder toRequest = createGeneralQueryFromContext(related, complex.getNested(main));
                    QueryBuilder hierarchicalFilter = buildHierarchicalFilter(fromType, toType, toRequest, main);

                    if (related.isMust()) {
                        joinChildrenFilter.must(hierarchicalFilter);
                    } else {
                        joinChildrenFilter.should(hierarchicalFilter);
                    }

                    // Support possible subsequent searches.
                    // Save generated subqueries, if children want to return something
                    // and have join fields
                    if (related.hasReturnFields() && related.hasJoinBy() && main.hasJoinBy()) {
                        result.put(related, toRequest);
                    }
                }

                baseQuery = QueryBuilders
                        .boolQuery()
                        .must(baseQuery)
                        .filter(joinChildrenFilter);
            }

            result.put(main, baseQuery);

        } else if (complex.getType() == ComplexSearchRequestContext.ComplexSearchRequestType.MULTI) {

            for (SearchRequestContext ctx : complex.getSupplementary()) {
                result.put(ctx, createGeneralQueryFromContext(ctx, complex.getNested(ctx)));
            }
        }

        return result;
    }

    /**
     * @param fromType  - from type
     * @param toType    - to type
     * @param toRequest - to request
     * @return hierarchical filter, only for one layer directed graph
     */
    @Nonnull
    private QueryBuilder buildHierarchicalFilter(HierarchicalIndexType fromType, HierarchicalIndexType toType,
                                                 QueryBuilder toRequest, SearchRequestContext main) {

        // Examples: relation to data or data to classifier or classifier to origin etc...
        if (!toType.isTopType() && !fromType.isTopType()) {
            HierarchicalIndexType top = fromType.getTopType();
            HasChildQueryBuilder child = JoinQueryBuilders.hasChildQuery(toType.getName(), toRequest, ScoreMode.None);
            return JoinQueryBuilders.hasParentQuery(top.getName(), child, true);
        }

        if (fromType.equals(toType)) {
            return QueryBuilders.boolQuery().must(toRequest);
        }

        if (toType.isTopType()) {
            return JoinQueryBuilders.hasParentQuery(toType.getName(), toRequest, true);
        }

        if (fromType.isTopType()) {
            return JoinQueryBuilders.hasChildQuery(toType.getName(), toRequest, ScoreMode.None);
        }

        throwInvalidState("Fail fast exception : unreachable state  was reached (rewrite code!)");
        return QueryBuilders.matchAllQuery();
    }

    /**
     * Creates aggregations.
     *
     * @param ctx the context to process
     * @return aggregation builder
     */
    protected AbstractAggregationBuilder<?> createAggregation(AggregationSearchContext ctx) {

        switch (ctx.getAggregationType()) {
            case CARDINALITY:

                CardinalityAggregationRequestContext caCtx = ctx.narrow();
                return AggregationBuilders
                        .cardinality(caCtx.getName())
                        .field(caCtx.getPath());
            case VALUE_COUNT:

                ValueCountAggregationRequestContext vaCtx = ctx.narrow();
                return AggregationBuilders
                        .count(vaCtx.getName())
                        .field(vaCtx.getPath());
            case FILTER:

                FilterAggregationRequestContext faCtx = ctx.narrow();
                FilterAggregationBuilder filterBuilder = AggregationBuilders
                        .filter(faCtx.getName(), createFormFilter(faCtx.getFields()));

                for (AggregationSearchContext inner : faCtx.aggregations()) {
                    filterBuilder.subAggregation(createAggregation(inner));
                }

                return filterBuilder;
            case NESTED:

                NestedAggregationRequestContext naCtx = ctx.narrow();
                NestedAggregationBuilder nestedBuilder = AggregationBuilders
                        .nested(naCtx.getName(), naCtx.getPath());

                for (AggregationSearchContext inner : naCtx.aggregations()) {
                    nestedBuilder.subAggregation(createAggregation(inner));
                }

                return nestedBuilder;
            case REVERSE_NESTED:

                ReverseNestedAggregationRequestContext rnaCtx = ctx.narrow();
                ReverseNestedAggregationBuilder reverseNestedBuilder = AggregationBuilders
                        .reverseNested(rnaCtx.getName())
                        .path(rnaCtx.getPath());

                for (AggregationSearchContext inner : rnaCtx.aggregations()) {
                    reverseNestedBuilder.subAggregation(createAggregation(inner));
                }

                return reverseNestedBuilder;
            case TERM:

                TermsAggregationRequestContext taCtx = ctx.narrow();
                TermsAggregationBuilder termsBuilder = AggregationBuilders.terms(taCtx.getName())
                        .field(taCtx.getPath())
                        .minDocCount(taCtx.getMinCount())
                        .size(taCtx.getSize());

                IncludeExclude include = null;
                if (taCtx.getExcludeValues() instanceof String[]) {
                    include = new IncludeExclude(null, (String[]) taCtx.getExcludeValues());
                } else if (taCtx.getExcludeValues() instanceof Long[]) {
                    include = new IncludeExclude(null, Arrays.stream(taCtx.getExcludeValues())
                            .filter(Objects::nonNull)
                            .mapToLong(Long.class::cast)
                            .toArray());
                } else if (taCtx.getExcludeValues() instanceof Double[]) {
                    include = new IncludeExclude(null, Arrays.stream(taCtx.getExcludeValues())
                            .filter(Objects::nonNull)
                            .mapToDouble(Double.class::cast)
                            .toArray());
                }

                IncludeExclude exclude = null;
                if (taCtx.getIncludeValues() instanceof String[]) {
                    exclude = new IncludeExclude((String[]) taCtx.getIncludeValues(), null);
                } else if (taCtx.getIncludeValues() instanceof Long[]) {
                    exclude = new IncludeExclude(Arrays.stream(taCtx.getIncludeValues())
                            .filter(Objects::nonNull)
                            .mapToLong(Long.class::cast)
                            .toArray(), null);
                } else if (taCtx.getIncludeValues() instanceof Double[]) {
                    exclude = new IncludeExclude(Arrays.stream(taCtx.getIncludeValues())
                            .filter(Objects::nonNull)
                            .mapToDouble(Double.class::cast)
                            .toArray(), null);
                }
                if (include != null || exclude != null) {
                    termsBuilder.includeExclude(IncludeExclude.merge(include, exclude));
                }

                for (AggregationSearchContext inner : taCtx.aggregations()) {
                    termsBuilder.subAggregation(createAggregation(inner));
                }

                return termsBuilder;
            default:
                break;
        }

        return null;
    }

    /**
     * Date object to TimeMillis.
     *
     * @param o
     *            object
     * @return Long timeMillis
     */
    private Long toTimeMillis(Object o) {

        if (o == null) {
            return null;
        } else if (o instanceof LocalDate) {
            return ConvertUtils.localDate2Date((LocalDate) o).getTime();
        } else if (o instanceof LocalDateTime) {
            return ConvertUtils.localDateTime2Date((LocalDateTime) o).getTime();
        } else if (o instanceof LocalTime) {
            return ConvertUtils.localTime2Date((LocalTime) o).getTime();
        } else if (o instanceof Calendar) {
            return ((Calendar) o).getTimeInMillis();
        } else if (o instanceof Date) {
            return ((Date) o).getTime();
        }

        LOGGER.warn("Cannot convert value of type [{}] to TimeMillis.", o.getClass().getName());
        return null;
    }

    /**
     * Execute request method, common to all agents.
     *
     * @param b an action request builder
     * @return action response
     */
    @SuppressWarnings({ "rawtypes", "unchecked" })
    protected final <R extends ActionResponse, B extends ActionRequestBuilder> R executeRequest(B b) {

        MeasurementPoint.start();
        try {

            long startTime = 0L;
            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug("Executing request: {}", b);
                startTime = System.currentTimeMillis();
            }

            R response = (R) b.execute().actionGet();

            if (LOGGER.isDebugEnabled()) {
                long endTime = System.currentTimeMillis();
                LOGGER.debug("Request executed in {} ms, with response {}.", endTime - startTime, response);
            }

            return response;
        } finally {
            MeasurementPoint.stop();
        }
    }

    /**
     * @param ctx context which contain all necessary info about entityName and storageId
     * @return index name
     */
    protected final String constructIndexName(@Nonnull TypedSearchContext ctx) {
        return constructIndexName(ctx.getEntity(), ctx.getStorageId());
    }

    /**
     * @param entity  - entity name
     * @param storage - storage
     * @return index name
     */
    protected String constructIndexName(@Nonnull String entity, @Nullable String storage) {

        StringBuilder b = new StringBuilder();
        if (indexPrefix != null) {
            b.append(indexPrefix.getValue())
             .append(SearchUtils.UNDERSCORE);
        }

        if (storage != null) {
            b.append(storage).append(SearchUtils.UNDERSCORE);
        } else {
            b.append(SearchConfigurationConstants.DEFAULT_INDEX_PREFIX).append(SearchUtils.UNDERSCORE);
        }

        return b.append(entity).toString().toLowerCase();
    }

    protected void throwInvalidState(String message) {
        throw new SearchApplicationException(message, SearchExceptionIds.EX_SEARCH_INVALID_STATE);
    }

    protected void throwInvalidMappingType(String message) {
        throw new SearchApplicationException(message, SearchExceptionIds.EX_SEARCH_MAPPING_TYPE_INVALID);
    }
    /**
     * Creates type term filter query for hierarchical indexes.
     * Returns original joinWith filter for non-hierarchical indexes.
     * Returns null if neither applies.
     * @param type the type to filter
     * @param joinWith a possibly existing post filter
     * @return query builder or null
     */
    @Nullable
    protected QueryBuilder createPostFilterQuery(@Nullable IndexType type, @Nullable FormSearchQuery joinWith) {

        if (Objects.nonNull(type) && type.isHierarchical()) {

            if (Objects.nonNull(joinWith) && !joinWith.isEmpty()) {
                return QueryBuilders.boolQuery()
                    .filter(createFormFilter(joinWith.getGroups()))
                    .filter(QueryBuilders.termQuery(SearchUtils.TYPE_FIELD_NAME, type.getName()));
            }

            return QueryBuilders.termQuery(SearchUtils.TYPE_FIELD_NAME, type.getName());
        }

        if (Objects.nonNull(joinWith) && !joinWith.isEmpty()) {
            return createFormFilter(joinWith.getGroups());
        }

        return null;
    }

    /**
     * Gets the current max. window size.
     * @return the window size
     */
    public Integer getMaxWindowSize() {
        return maxWindowSize.getValue().intValue();
    }
}
